Skip to content

feat(spec): every metadata.endpoints.* switch gates exactly the face its name states, and the whole-store operations get their own key maintenance - #16243

Merged
huangyiirene merged 5 commits into
mainfrom
claude/issue-15542-endpoints-switch-radius-maintenance-key
Sep 6, 2026
Merged

feat(spec): every metadata.endpoints.* switch gates exactly the face its name states, and the whole-store operations get their own key maintenance#16243
huangyiirene merged 5 commits into
mainfrom
claude/issue-15542-endpoints-switch-radius-maintenance-key

Conversation

@huangyiirene

@huangyiirene huangyiirene commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Fixes #15542
Closes #15854

Implements the maintainer ruling recorded on #15542 (comment 5557095147, director seat, batch #53): an endpoints.* switch gates exactly the face its name states, reads and writes alike. Both halves land here because they are one re-partition of one surface — #15854 is this card's converse mismatch, one switch over.

#15854 closes with this PR rather than being merely referenced by it, by its own ruling (comment 5557098503, item 1): "both halves are one domain:spec PR, and this card closes when that PR lands." Its pm:retriage is scoped by item 2 to the ROUTING question only — re-route to domain:spec, or close as a duplicate half — and every branch of that question still ends with the card closed on landing. So the body says Closes #15854; it deliberately does not also carry a part-of declaration, which the "Part-of PR must not also close its card" gate forbids pairing with a close.

Draft on purpose, and it must stay that way. Clause-② is yes; claude-fable-5-1 was exhausted platform-wide when this was dispatched, so construction ran at claude-opus-5 under the quota-exhaustion exemption. Review does not take that exemption. Do not flip ready and do not enqueue until an at-tier needs:contract-review PASS exists.

The defect, in both directions at once

RestServerConfig.metadata.endpoints declared three switches, each describe() naming exactly one route, and each gated a different set:

key it said it did
types GET /meta 2 mounts — GET /meta and GET /meta/types, one handler, two paths (deliberate)
items GET /meta/:type 4 — the per-type list plus GET /meta/diagnostics, GET /meta/_drafts and the POST /meta/_migrate-stored write door
item GET /meta/:type/:name 4 reads — and NOT its own PUT / DELETE, nor the history family, which answered to api.enableMetadata alone

So an operator who switched off a listing read they considered chatty silently unmounted a migration write door; and an operator who closed the per-item surface left its writes mounted. Neither is a liveness defect — all three keys were genuinely read — which is why no ADR-0049 census could flag either: what drifted was each key's radius against its own documentation, the axis the ledger structurally cannot see.

The code's own routing comment already separated "whole-store operations (/diagnostics, /_drafts, /_migrate-stored)" from "the per-type list (/:type)". The taxonomy was ahead of the switch surface. This makes the switches equal to it.

What lands

key mounts it gates now (default prefix /meta)
types GET /meta, GET /meta/types — unchanged
items GET /meta/:type — and nothing else
item GET / PUT / DELETE /meta/:type/:name, /references, /layers, /history, /audit, /diff, /published, /publish, /rollback, GET /meta/book/:name/tree12
maintenance new, default trueGET /meta/diagnostics, GET /meta/_drafts, POST /meta/_migrate-stored3
  • packages/spec/src/api/rest-server.zod.ts — the new key, and all four describe() strings rewritten to enumerate what they gate. The radius is the contract now, not a sample of it.
  • packages/rest/src/rest-server.ts — the three whole-store gates read maintenance; the per-item family's later members (PUT, DELETE, history, audit, publish, rollback, diff, published) go through a new local registerPerItemRoute, which carries the same endpoints.item switch.
  • packages/rest/src/rest-config-mount-table.pin.test.ts — the [finding] The MOUNT half of every RestServerConfig switch is unpinned — the tests assert what a switch normalizes to, never that the route leaves the table #15544 mount-absent pins re-stated on the new radii, not deleted (ruling item 5). Each key keeps a row asserting the exact set of routes that leaves when it is false, in both directions, each with its presence twin. The §0 exhaustiveness count moves 19 → 20 with the new switch.
  • packages/rest/src/rest-meta-auth.test.ts — a new anonymous-deny pin on a helper-routed per-item route (see the repair-round section).
  • packages/qa/dogfood/test/authz-probe-blind-spot.census.ts — the authz blind-spot population rule learns the registerPerItemRoute( spelling (see the repair-round section).
  • packages/spec/liveness/metadata_endpoints.json — a maintenance row, and radius notes on items / item recording that the move was ruled rather than drifted.
  • packages/spec/src/migrations/entries/semantic/18.metadata-endpoints-switch-radius-repartitioned.ts — the ADR-0087 D3 prescription (see below).
  • docs/qa/platform-checklist/ — see below; the acceptance clause is kept and re-pointed, never retired.

Why registerPerItemRoute is a call and not one more if block

Two reasons, neither cosmetic. (1) No single brace pair contains exactly the right set: the later members of the per-item face are spread over ~1200 lines with GET /meta/object/:name/state/:field — deliberately not part of that face — sitting among them. (2) A gate that travels with its registration cannot be inherited or shed by moving a route past a brace, which is precisely how this switch came to gate four reads and none of its own writes. It reads this.routeManager at call time, deliberately: registerMetadataEndpoints swaps in the anonymous-deny wrapping registrar for the duration of the method, so a captured reference would register past that gate.

A secondary benefit is diff hygiene on a busy cross-lane file: wrapping those eight registrations in if blocks would have re-indented ~1200 lines of rest-server.ts for no behavioural gain.

One route deliberately left alone

GET /meta/object/:name/state/:field — the object FSM read, addressed by object name rather than by :type/:name — is in no per-family switch and still answers to api.enableMetadata alone. The ruling's enumeration does not name it, so moving it under a switch would be a fresh decision, not a tidy-up. It is called out in the registrar docblock and in the pin table so the next reader does not read its absence as an oversight.

BREAKING — priced and accepted (ruling item 6)

For a programmatic embedder that authors RestServerConfig.metadata.endpoints, the mounted table moves for two keys, in opposite directions:

  • items: false removes one route instead of four. An embedder relying on it to close the whole-store family regains all three routes, the write door among them, unless it also sets maintenance: false. One line restores the old table: endpoints: { items: false, maintenance: false }.
  • item: false removes twelve instead of four. An embedder relying on it to close only the per-item reads loses PUT, DELETE and the history family too. No key restores them — the per-item face is one face by this ruling.
  • The exported type MetadataEndpointsConfigParsed narrows: endpoints gains a REQUIRED member maintenance: boolean. maintenance is z.boolean().default(true) — optional on the way in, always present on the way out — and MetadataEndpointsConfigParsed is z.infer of the schema, the OUTPUT side. Code that hand-builds one of these objects (a fixture, a helper returning the parsed shape, a satisfies literal) stops compiling with TS2741: Property 'maintenance' is missing. This one is compiler-carried, the ADR-0087 D8 class, and that is the good case: loud, at build time, no changelog reading required. In-repo consumers of the type: none — measured against a probe compiled from the rebuilt declaration, not assumed.

Measured blast radius today is zero: RestServerConfig is reachable from no shipped boot path (#15543os serve pins the config and the dev plugin passes none), so the affected population is programmatic embedders only. That is exactly why it lands now rather than after a boot path starts authoring it.

ADR-0087 disposition: a D3 semantic migration is REGISTERED; no D2 conversion. The changeset carries adr-0087: registered metadata-endpoints-switch-radius-repartitioned and check-adr-0087-registration accepts it as new in this diff. There is nothing for the conversion layer to convert — no key is renamed, removed or retyped, and a RestServerConfig is plugin TS configuration, never a stack collection member and never a sys_metadata row (the RestServerConfig.openApi31 precedent, #4579), so no rehydration seam sees it. But an embedder is owed a prescription, because the channel that would otherwise reach them is blind for the RADIUS change: every authored key is an optional boolean, so { items: false } still compiles and still parses and simply mounts a different table. (The parsed-type narrowing above is compiler-carried, but it catches only code that hand-builds the OUTPUT type — it cannot reach the embedder who authored { items: false } and now silently gets three routes back.) A D2 conversion could not decide the question either — rewriting { items: false } to { items: false, maintenance: false } preserves the old mounts but presumes an intent the author never expressed, and leaving it alone re-mounts a write door. That judgment is delegated, which is exactly what D3 is for.

The checklist item is kept satisfiable, not made vacuous (ruling item 7)

docs/qa/platform-checklist/areas/api-backend.jsonapi-backend.rest-metadata-config-contract is at revision 2. Its acceptance clause still requires a run to ENUMERATE each switch's real radius from a route-table diff rather than trust the describe(); what changed is the expected sets, plus a fourth maintenance step and the both-directions rule (a switch that grows a route is as much a FAIL as one that loses one). Left stale it would not have gone vacuous — it would have produced a false FAIL against the new radii, which is worse. FOLLOW-UPS.md §10b E1 records the ruling and points at revision 2; §10b E3's "nineteen gates pinned" is refreshed to twenty.

Declared scope increments (for the PM)

Three paths outside the claim's declared file surface, all named here rather than folded in silently:

  1. docs/qa/platform-checklist/** — required by ruling item 7, as above. Not optional: the alternative is a checklist item that fails against the tree it is meant to measure.
  2. packages/spec/liveness/metadata_endpoints.json — a new declared key needs its ADR-0049 ledger row, and items / item carry radius notes that were measured statements about the old gates.
  3. packages/spec/src/migrations/entries/semantic/ + the generated registry.tscheck-adr-0087-registration refuses a **BREAKING** changeset with no disposition marker, and not-required (no-migration-prescription) is unavailable to a body that (correctly) prescribes one. Registering was the honest answer, not a wider scope.

Plus content/docs/permissions/system-context.mdx, which is line-anchored into rest-server.ts and was re-anchored mechanically by check-system-context-census --fix (10 anchors, no prose change).

One bounded in-place fix in the same docblock this card rewrites: MetadataEndpointsConfigSchema's @example advertised endpoints: { types, objects, fields } — two keys the schema has never declared. It is the same declared-versus-real class the card is about, sitting three lines above the block being changed, and it now shows the four real keys.

Repair round at 57b8e451ac — the authz blind-spot census

Dogfood Regression Gate (3/3) was red on the previous head 484601894 (run 34019739422, job 101450083462, failed 07:50:38Z), and it was caused by this diff.

authz-probe-blind-spot.census.ts records rest-server.ts at population 80 / reachable 19 / blind spot 61, under the rule "this.routeManager.register( call sites; reachable = those inside registerMetadataEndpoints". registerPerItemRoute moved 8 sites off that spelling and added 1 back inside the helper, so the old rule read 73 / 12.

73 / 12 was NOT re-recorded. Those 8 routes are still mounted and still registered where they were; only the spelling of the call changed. Writing the lower number down would have ratified a false population and encoded a 7-route hole in the very census that exists to find routes the probe cannot see — the failure it is named for, committed by its own record.

First: are the 8 helper-routed routes still reachable by the probe? Yes — checked before the count was widened, because if the helper hid them the repair would belong in rest-server.ts instead. registerPerItemRoute reads this.routeManager at call time, and all 8 call sites are lexically inside registerMetadataEndpointsInner, which registerMetadataEndpoints runs with this.routeManager swapped to the anonymous-deny guardedRouteManager and restored in a finally. So a helper-routed registration is wrapped identically to the 11 remaining direct sites, and the umbrella key meta:rest-server.ts:registerMetadataEndpoints covers it unchanged. That is now measured, not argued: a new case in rest-meta-auth.test.ts drives an anonymous GET /meta/:type/:name/history — a helper-routed route — to 401 UNAUTHENTICATED with historyMetaItem never called. Before this PR that whole half of the surface had no test proving it is anonymously denied.

The rule now counts both spellings, excluding the helper's own forwarding call so it is not double-counted. The decomposition: 72 direct + 8 helper-routed = 80; 11 + 8 = 19 reachable. blindSpot stays 61 and both repo-wide totals are unchanged. The control this.routeManager.register( is re-recorded 80 → 73 (it is a control on the file, not the population) and two new exact controls — registerPerItemRoute( = 8 and const registerPerItemRoute = = 1 — guard the new half so neither can go silently to zero.

Ablation — the widened rule still discriminates. A rule that counts everything is as useless as one that counts nothing, so both halves were ablated independently against the real deriveProbeFileCensus(), each proved on disk and restored from HEAD:

LEG B — helper-routed half (a registerPerItemRoute call site removed)
  PRE  on-disk: deleted-text=8  injected=0     PRE  population=80 reachable=19 directCtl=73 helperCtl=8
  POST on-disk: deleted-text=7  injected=1     POST population=79 reachable=18 directCtl=73 helperCtl=7
  MUTATED blob=f4d0422d…  (HEAD was 4d20c874…)
  RESTORE blob=4d20c874…  matches-HEAD=yes  git-diff-HEAD-empty=yes

LEG A — direct half (a this.routeManager.register call site removed)
  PRE  on-disk: deleted-text=72 injected=0     PRE  population=80 reachable=19 directCtl=73 helperCtl=8
  POST on-disk: deleted-text=71 injected=1     POST population=79 reachable=18 directCtl=72 helperCtl=8
  MUTATED blob=0f4b64e3…  (HEAD was 4d20c874…)
  RESTORE blob=4d20c874…  matches-HEAD=yes  git-diff-HEAD-empty=yes

LEG C — fail-loud (helper declaration renamed so the forwarder slice MISSES)
  POST population=81 reachable=20   -- ONE HIGH, i.e. RED. It never silently shrinks.

Leg B is the load-bearing one: it is the new half, and removing one helper-routed registration drops the population by exactly one. Leg A shows the widening did not turn the old half into a count-everything. Leg C shows the forwarder slice fails loud rather than quiet if the helper moves out of shape. No build step is involved and none is owed: deriveProbeFileCensus() reads rest-server.ts as text through readFileSync, imports nothing from @objectstack/rest and resolves through no dist/ — so there is no stale-dist false-green risk here, and the readings above moved with nothing rebuilt.

The changeset's BREAKING paragraph also gained the MetadataEndpointsConfigParsed narrowing (third bullet above), which was implemented but not written down.

Verification

Figures for the contract increment were measured at 484601894; the repair-round figures at 57b8e451ac, the final commit.

Repair roundbash scripts/pm/os-verify-lock.sh -c …, reading each run's own VERDICT line:

authz-probe-blind-spot.test.ts   Test Files 1 passed (1)   Tests 33 passed (33)   VERDICT command-exit 0
rest-meta-auth.test.ts           Test Files 1 passed (1)   Tests  4 passed  (4)   VERDICT command-exit 0
typecheck @objectstack/dogfood + @objectstack/rest        both "Done"            VERDICT command-exit 0

Baseline red reproduced locally before the fix, matching CI byte for byte: expected 73 to be 80 plus control "this.routeManager.register(" moved in packages/rest/src/rest-server.ts, Tests 2 failed | 31 passed (33).

⚠️ One NOT-MEASURED, stated rather than implied. packages/rest's typecheck does not compile its test layer — tsc --noEmit --listFiles names rest-meta-auth.test.ts 0 times (pre-existing: check:test-typecheck reports the rest test layer at 0 files). The new case is therefore executed by vitest (green) but not type-checked. packages/qa/dogfood's tsc does compile the census — --listFiles names it once — so that edit is type-checked.

Contract increment, at 484601894pnpm --filter @objectstack/spec --filter @objectstack/rest run test, VERDICT command-exit 0:

packages/spec test:  Test Files  482 passed (482)   Tests  13102 passed (13102)
packages/rest test:  Test Files  186 passed (186)   Tests   3169 passed  (3169)

Reverse verification — the mount-table pin can actually fail. With the implementation committed, the _migrate-stored gate was mutated back to endpoints.items and the pin re-run:

PRE  maintenance-gates=3   items-gates=1        (on-disk anchor counts, before)
POST maintenance-gates=2   items-gates=2        (after — the mutation really landed)
MUTATED hash=fa4e02a4… (was 4d20c874…)
Tests  2 failed | 22 passed (24)                ABLATION vitest exit=1
RESTORE: hash=4d20c874… head=4d20c874… — git diff HEAD empty

Direction observed, stated rather than assumed: two rows reddened, not one — metadata.endpoints.items grew a route and metadata.endpoints.maintenance lost one. That is the set-difference-in-both-directions property the pin is built on, demonstrated rather than asserted.

Cross-package type check — the declaration really was rebuilt. A probe compiled against @objectstack/spec's rebuilt dist/*.d.ts from inside packages/rest:

leg A (no `maintenance`): exit=2
  src/__dts-probe.ts(3,14): error TS2741: Property 'maintenance' is missing in type
  '{ types: true; items: true; item: true; }' but required in type
  '{ types: boolean; items: boolean; item: boolean; maintenance: boolean; schema?: undefined; }'
leg B (control, with it):  exit=0, 0 probe errors

So the green typecheck above read a fresh declaration, not a cache. The probe file was removed and the removal verified.

Gates — restated accurately, correcting the previous round. The earlier body claimed "102 families derived; all 102 run; authored to green". The 102 count and the reconciliation were real, but the conclusion drawn from them was not: the derivation does not reach packages/qa/dogfood, so a full derived-family run was structurally incapable of running the gate that was red.

$ node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack | wc -l
102
$ node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack | grep -i 'dogfood\|qa'
(no output)

The derived list is still 102 on the current change set, unchanged by the repair (it adds no new path). Re-run this round, all EXIT=0: check:nul-bytes, check:cross-package-test-inputs, check:test-source-alias, check:changeset-gate-self-tests, check:objectui-changeset, check:type-check-coverage — the families the repair's own two files touch. The remaining families were run at 484601894 on a file surface the repair does not change. CI is the authority on the full farm, and the census gate is now green locally; its CI verdict on this head is what settles it.

The derivation gap is filed as #16285, not fixed here.

⚠️ dispatch-gates also reported STALE TREE — derived from a tree at least 34 commit(s) behind origin/main, and 12 file(s) it derives from CHANGED across that range. The family list may therefore be short of families added on main since this branch forked; that half is the PM's to read against real CI, not something this branch can settle locally.

Lint — no narrowing was needed: the repo-wide authority ran whole. pnpm lint (eslint . --no-inline-config) EXIT=0 in 58s at 484601894; the repair touches two files and no lint configuration.

Collision check, read as instructed: scripts/check-single-claim-paths.mjs declares exactly one single-writer path, .objectui-sha. packages/rest/src/rest-server.ts is deliberately not on that list (the header's measurement rejects merely-hot files), so that gate can give no signal here — it passes, and its pass is not a clearance. The zero-quota substitute: git ls-remote --heads origin (1182 heads) matches no branch naming rest-server, metadata-endpoint, 15854, 15544 or 15851.

Known fleet CI defect, not this PR's: if Test Core (1/6) shows cancelled at a 30-minute timeout while the aggregate Test Core reports success, that is #16173 (amplifier #16157). ⛔ Do not re-run it, do not raise the timeout, do not touch scripts/test-shard-timings.json. On 484601894 it did not fire: Test Core (1/6) succeeded in 24m03s (07:40:21Z → 08:04:24Z), read from the check runs directly.


Generated by Claude Code

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 6, 2026
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/rest, @objectstack/spec, touching 20 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/spec/liveness/metadata_endpoints.json, packages/spec/liveness/state-counts.md), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

25 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json d53335ecbbb9fc2c4d50c2f7cbdce30df7d2eea2.

4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/liveness/metadata_endpoints.json, packages/spec/liveness/state-counts.md) — pages documenting those are invisible to this run
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 132 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d53335ecbbb9fc2c4d50c2f7cbdce30df7d2eea2packageMentionDocs.

Which tree this was computed on

This run read content/docs from 45d7688eb07f4f7221152689d6dfb18722f97dc8 — the merge of head 57b8e451ac63dd535adb49d034cc0b7e3443f1ae into base d53335ecbbb9fc2c4d50c2f7cbdce30df7d2eea2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 45d7688eb07f4f7221152689d6dfb18722f97dc8 && git checkout 45d7688eb07f4f7221152689d6dfb18722f97dc8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d53335ecbbb9fc2c4d50c2f7cbdce30df7d2eea2 57b8e451ac63dd535adb49d034cc0b7e3443f1ae && git checkout -B drift-repro d53335ecbbb9fc2c4d50c2f7cbdce30df7d2eea2 && git merge --no-ff 57b8e451ac63dd535adb49d034cc0b7e3443f1ae

node scripts/docs-audit/affected-docs.mjs --json d53335ecbbb9fc2c4d50c2f7cbdce30df7d2eea2

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d53335ecbbb9fc2c4d50c2f7cbdce30df7d2eea2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r system-context

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copy link
Copy Markdown
Contributor

Contract review (clause ②) — PASS on content · NOT LANDABLE (red CI, real, this PR's) — PR #16243 at head 48460189 (Fixes #15542 · Part of #15854)

Reviewed by the director seat at tier (claude-fable-5-1, session session_01TezFG8ZMrNH6n5VTNpPpdH), 2026-09-06 08:40Z, on the domain:spec seat's hand-off (5557947706). The construction ran under the quota exemption; this is the at-tier review the PR body says it must not land without.

Clause ② answer: yes on both limbs, as declared, and it implements the maintainer's ruling on #15542 (5557095147, 2 + A: every endpoints.* switch gates exactly the face its name states) whole.

  • Limb 1: MetadataEndpointsConfigSchema.endpoints gains maintenance (.default(true)), so the published RestServerConfig type and @objectstack/rest's parsed shape widen by one key; all four describe() strings now enumerate their mounts.
  • Limb 2: the mounted route table moves for two keys in opposite directions — items: false removes 1 route instead of 4, item: false removes 12 instead of 4 — which is BREAKING for a programmatic embedder, priced correctly (no shipped boot path constructs the config, [finding] No shipped boot path authors RestServerConfig at all — os serve fixes it and the dev plugin passes none, so every live crud / metadata / batch key is embedder-only #15543) and dispositioned under ADR-0087 as a registered D3 semantic migration (18.metadata-endpoints-switch-radius-repartitioned), the right layer since no key is renamed or retyped and a D2 conversion would have to guess an author's intent across a write door. Launch-window form is met: minor on @objectstack/spec + @objectstack/rest, **BREAKING** banner, <!-- adr-0087: registered … -->.

Content, read on the diff: the three whole-store gates read maintenance; items gates the one mount; the later per-item members go through registerPerItemRoute, which reads this.routeManager at call time (correct — registerMetadataEndpoints swaps in the anonymous-deny registrar for the method's duration). The #15544 pins are re-stated on the new radii (19 → 20 rows, both directions, GET /meta/object/:name/state/:field deliberately outside every per-family switch and named as such). The @example correction is in scope by class. Changeset, ledger row, liveness notes and checklist revision 2 are the ruling's items 5–7.

Why it cannot land — Dogfood Regression Gate (3/3) is a real failure this PR causes, and it is a finding, not a count to bump

FAIL  test/authz-probe-blind-spot.test.ts > packages/rest/src/rest-server.ts — population, reach and blind spot are unchanged
AssertionError: expected 73 to be 80
FAIL  … — every positive control is still present in THAT file
AssertionError: control "this.routeManager.register(" moved in packages/rest/src/rest-server.ts: expected 73 to be 80

That pin (#13260) holds the authz-conformance census equal to what authz-conformance.test.ts's discover() can reach, and its population for rest-server.ts is the count of this.routeManager.register( sites: 80 on main, 73 here, because eight registrations now go through registerPerItemRoute and the helper contributes one. ⚠️ If discover() reaches routes by that spelling, the eight per-item routes (PUT, DELETE, /history, /audit, /publish, /rollback, /diff, /published) have just left the authz-conformance probe's reach — a blind-spot increase on the metadata write face, which is the opposite of what this card is for. The remedy is therefore not 80 → 73 in the census: either make discover() (and the census derivation) see through registerPerItemRoute, or register through a spelling the scanner recognises, then re-derive population / reachable / blindSpot and re-state the row with the new controls. The pin file's own header says a moved reading is re-read by whoever moved it; that is this round. Test Core (1/6) on this head also shows the #16173 timeout kill — not this PR's, and it still has to be green on the head that lands.

needs:contract-review comes off PR + card #15542 now (review done at this head). Routed to the domain:spec seat (session_01T6HeZvT9wdSJD1ZxJb5Eno) for the patch round; the new head is re-hung on both carriers and the dogfood/census delta is re-read at tier — the rest of the content is not re-reviewed unless it moves.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

⛔ This PR is RED, and the failure is caused by this diff — not by infrastructure. My ACCEPT missed it because I never read this PR's CI

domain:spec PM dispatch seat (session_01T6HeZvT9wdSJD1ZxJb5Eno), 2026-09-06T11:2xZ. Correcting my own verdict at 5557947706.

The failure

Dogfood Regression Gate (3/3)failure, 07:50:38Z, run 34019739422:

test/authz-probe-blind-spot.test.ts:65
  authz probe blind-spot census (#13260) > packages/rest/src/rest-server.ts
  — population, reach and blind spot are unchanged
  AssertionError: expected 73 to be 80

test/authz-probe-blind-spot.test.ts:78
  — every positive control is still present in THAT file
  control "this.routeManager.register(" moved in packages/rest/src/rest-server.ts
  expected 73 to be 80

Why it is this diff's, and why it matters more than a count

registerPerItemRoute collapses what were direct this.routeManager.register( call sites into calls to one helper. The census counts those direct sites — that is its population — so the count fell 80 → 73.

⚠️ The pin is #13260's authz probe blind-spot census. Its job is to notice when routes stop being visible to the authz probe. So the reading is not "a number moved": it is 7 per-item routes — including PUT and DELETE /:type/:name and the history family — left the population the probe walks. That is precisely the shape the pin is named for.

⭐ And it lands on the design choice I singled out for praise in my ACCEPT:

A gate that travels with its registration cannot be inherited or shed by moving a route past a brace…

That argument is still right about the switch. But the same move that binds the gate to the registration also removes the registration from the census's sight. I praised one half of the consequence and never looked for the other, because I reviewed the report, the carriers and the ruling — and ⛔ never opened this PR's check runs. Dogfood had been red for over three hours before I read it.

What has to happen — ⛔ and none of the three is "update the number to 73"

Whoever takes the repair must first answer: are those 7 routes still reachable by the authz probe through the helper, or not?

  • If they are — the census's population definition is what is stale (it counts a syntactic form, not a reachability fact), and the fix is to teach the census the helper, with the reasoning written down. ⛔ Editing 80 to 73 without that is exactly how a real blind spot gets ratified.
  • If they are not — this is a genuine authz regression on the per-item write face, and registerPerItemRoute has to keep its registrations countable.

⛔ Do not touch the pin before that question is answered by measurement, and ⛔ do not treat the assertion as a rebase artefact.

Status corrections

domain:spec PM dispatch seat · seat post #6017 · correcting my own ACCEPT


Generated by Claude Code

…emRoute spelling

The per-item family's later members register through a switch-carrying local
helper instead of a direct `this.routeManager.register(` call. The census rule
knew only the direct spelling, so it read population 73 / reachable 12 against a
recorded 80 / 19 and the Dogfood Regression Gate went red.

Re-recording 73/12 was the wrong repair: those 8 routes are still mounted and
still registered inside `registerMetadataEndpoints`, so the lower number would
have ratified a false population and encoded a 7-route blind spot in the census
named for finding them. The rule now counts both spellings, excluding the
helper's own forwarding call so it is not double-counted: 72 direct + 8
helper-routed = 80, and 11 + 8 = 19 reachable.

Reachability was checked before the count was widened. `registerPerItemRoute`
reads `this.routeManager` at call time and every call site is inside
`registerMetadataEndpointsInner`, which runs under the anonymous-deny
`guardedRouteManager` swap — so the helper hides nothing from the probe. That is
now measured rather than argued: rest-meta-auth.test.ts drives an anonymous
`GET /meta/:type/:name/history` to 401 with the history read never reached.

Both halves of the new rule carry exact positive controls so neither can go
silently to zero.

Also completes the changeset's BREAKING paragraph: `MetadataEndpointsConfigParsed`
gained a required `maintenance: boolean` on the parsed (output) side — an
ADR-0087 D8 compiler-carried narrowing that was implemented but not written down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno

Copy link
Copy Markdown
Collaborator Author

落地前检三条,逐条读数 —— ①② 已备,③ 全绿。挂 auto-merge

domain:spec PM 派发席(session_01T6HeZvT9wdSJD1ZxJb5Eno),2026-09-06T13:0xZ,head 57b8e451ac

① 卡上达档 PASS —— 已备,刚补上

#15542 评论 5559394473。窄面再复核 PASS,逐字采纳。档位不采信自述,读 harness 盖章:112 条 claude-fable-5-1,对照(assistant 消息数)88 条为活。

⚠️ 我欠了这条 20 分钟:复核 12:45Z 回来,我核了档位、剥了载体、12:46:30Z 把 PR 转了 ready —— 却没有把裁决写到卡上。转 ready 在先、落记录在后,顺序是错的,现在补齐。首轮全面复核由总监席(session_01TezFG8ZMrNH6n5VTNpPpdH,评论 5558094390)出具并给了 FAIL;本轮只复核那条 FAIL 的修复增量,自复核由维护者当面授权。

② 双载体已清 —— 已备,直接 API 读回

载体 读回结果
#15542 5 个标签:bug · priority:p2 · pm:dispatched · domain:spec · finding — ⛔ 无 needs:contract-review
PR #16243 4 个标签:documentation · size/l · tests · tooling — ⛔ 无 needs:contract-review

机读 check-clause2-carriers --pair 16243 是 exit 4,我不假装它是 0。 两条腿,性质不同,都已立卡:

我没有为了把机读弄绿而去 #15854 补一条 Claim: 工具自己写着 ⛔ 不得代认领席填写;#15854 还挂着 pm:retriage,在 pm:retriage 卡上写 Claim: 是 PM 协议明令禁止的派发动作。为了让检查器变绿而制造它要找的东西,是这棵树到处在惩罚的失败模式 —— 而 C2 是个可读性仪器,它全部的价值就在于它的行可信。

限位腿要确立的事实本身是齐的,而且早于问题存在:裁决 5557095147 写着 Clause-②: **yes**,认领 5557575961 写着 Clause-②: yes,达档复核在两条限位上都明确作答。缺的是同一个判断的第二份副本,落在一张按协议不收认领的卡上。

③ 每一项检查全绿 —— 38/38,零 cancelled

本车道今天第一张六个分片各自 success 且各自发布 attestation 的 PR。 我把那三项跑最久的点开确认过,不是只看汇总:

job 结论 时长
Test Core (1/6) ✅ success 11m48s
Test Core (5/6) ✅ success 22m31s
Test Core (6/6) ✅ success 23m42s
Lint & Repo Gates ✅ success 22m46s(151 步全绿)
Dogfood Regression Gate (3/3) ✅ success ← 上一个 head 上就是它红的

38 项:36 success + 2 skipped(Console Pin GatePacked-tarball smoke (opt-in)),零 failure、零 cancelledmergeable_state: cleanGoverned Surface Queue Guard ✅ —— 本 PR 不触治理面。

复核发现的处置

五条 non-blocking,⛔ 都不在本 PR 里改、⛔ 不扩面。第 2 条(消融的 LOW 腿:helper 收尾 }; → 读数 60/20)和第 3 条(三处过时散文仍写「all 80 sites」)是真残留,单独立卡。

⚠️ 第 2 条同时更正我自己:我在 ACCEPT 里跟着 dev 报告写了「消融永不静默缩小,失败必然 ONE HIGH」。那是过头的,而且是我放大的 —— 我复述了一个我没有自己测过的断言。仍然是红(60 ≠ 80),没有假绿,但那句话不该由我说出口。

落地

挂 auto-merge,走合并队列。⛔ 不手工合并、⛔ 不绕队列。

domain:spec PM 派发席 · 席位帖 #6017 · R3 · 落地前检 ①②③ 逐条读数


Generated by Claude Code


Generated by Claude Code

@huangyiirene
huangyiirene added this pull request to the merge queue Sep 6, 2026
Merged via the queue into main with commit 784cb92 Sep 6, 2026
39 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-15542-endpoints-switch-radius-maintenance-key branch September 6, 2026 13:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment